Skip to content

descr: catch the field-position invariant where it is produced, not where it is repaired - #1053

Merged
youknowone merged 8 commits into
mainfrom
gc-decouple
Aug 5, 2026
Merged

descr: catch the field-position invariant where it is produced, not where it is repaired#1053
youknowone merged 8 commits into
mainfrom
gc-decouple

Conversation

@youknowone

@youknowone youknowone commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

A field descr's index_in_parent (descr.py:228) must name the slot its parent
puts the field in — upstream gets this for free because heaptracker.py:60-72 get_fielddescr_index_in and :96-112 all_fielddescrs are one walker sharing one
skip set. pyre has several independent producers, and
all_fielddescrs()[index_in_parent] is a load-bearing lookup
(optimizeopt/info.rs force_box), so a stale rank either runs off the end or
emits the store against a different field.

Two producers were leaving that rank stale, and the counter that looks like it
guards this could not see either of them.

The counter could not have caught this

field_pos_rederived was documented as "the one to watch". It cannot be:

  • GcCache::derive_index_in_parent is the judge and the repairman — it
    replaces the caller's number with the parent's in the same expression, so a
    nonzero reading is a log of repairs already applied, never a defect still
    present.
  • It mostly never asks. make_simple_descr_group_keyed_with_headerless mints
    every field before register_keyed_size publishes the parent those fields
    will be indexed against, so the dominant path takes the FIELD_PARENT_ABSENT
    early return. Measured on this tree: parent_absent 1202 against rederived
    0.

General shape, worth keeping: a counter placed inside a normalising reader
measures repairs, not defects, over whatever subset the reader chose to examine.

What replaces it

Two censuses taken before anything can normalise, each with a real denominator:

counter what it reads this tree
field_pos_spec_{checked,misplaced} a parent's positional list as handed to either group factory, including the cache_key == 0 groups that never enter _cache_size 1712 / 0
field_pos_attached_{checked,misplaced} a standalone field descr against the parent its own producer attached 247 / 0

Both *_misplaced join JITSTATS_BADNESS_FIELDS. A field absent from a baseline
reads as 0, so they gate from the first run with no re-record.

…and why a census was still not enough

Negative control, fix reverted, full corpus: all four of field_pos_rederived,
field_pos_spec_misplaced, field_pos_attached_misplaced and
positional_misplaced still read 0, with attached_checked unchanged at 248.
The defect is input-order dependent — it needs a field minted before a
lower-offset sibling registers — so a corpus that registers in offset order
exercises the producer without exercising the bug. Building a better census did
not help; that is the finding.

So the load-bearing check is at the construction site.
field_descr_position_disagreement runs at try_finish under
cfg!(debug_assertions) over every emitted Field descr, covering all three
states the pass can leave behind: the offset resolves (index and name are that
slot's), the offset is absent (the (0, "") fallback is intact — those are the
inline aggregates heaptracker.py:68-69 mints no descr for), and the parent's
type_id is absent from struct_size_specs (the continue that patched
nothing). Removing the re-resolution panics with
field descr at offset 16 … claims slot 0 named "hi", but that offset is slot 1.

The two producers

  1. jitcode/assembler.rspatch_field_descr_parents already swapped the
    snapshotted parent for the final merged spec but left index_in_parent /
    name, which came from that same snapshot, so one descr's two halves
    described different fields. register_struct_layout re-indexes on every
    merge, which is what invalidates the snapshot.
  2. codewriter/assembler.rsfielddescrof's struct_layout_for and
    heuristic_field_layout arms overwrite offset from a second source and
    leave index_in_parent at its 0 initialiser while parent carries the
    whole flattened list. The found_parent_field arm is deliberately left alone:
    it picked its spec by name, and re-resolving by offset would return the wrong
    one for a struct with two fields at one offset.

Both fixes are pinned by a test that constructs the state directly, each with the
revert-and-fail control shown.

wasm

The two new badness fields would have read absent-and-therefore-zero on wasm, the
exact hole the descr_set_* exports were added to close. Exported the same way.
Not redundant with the native readings — the invariant is stated in byte offsets
and wasm32 lays structs out on a 4-byte word (symbolic.py:12); the wasm run
reports its own denominators (spec_checked=1665, attached_checked=245 against
1712/247 on the dev host), so it is measuring the wasm-side universe.

Note on the first three commits

738a524 / 39bd8a9 add an executed-allocation GC root list and then patch a
crash that root itself introduced; e8204d9 removes both. They net to zero and
are kept as the record: the finding they answered ("the execute_new result
lives only in a borrowed slice that is not a GC root") is false — the result
is stamped onto the recorded op's value cell (history.py:803-807 *FrontendOp(pos, value)) and MetaInterp::walk_active_trace_refs has forwarded
those cells since d3bfd17. Say the word and I will fold them out.

Verification

check.py dynasm 378/378, cranelift 378/378, wasm 374/374;
cargo test --workspace --no-default-features --features dynasm 101 suites, no
failures; cargo fmt --all --check clean. No jitstats baseline was re-recorded.

authored by Claude

Summary by CodeRabbit

  • New Features

    • Added JIT field-position diagnostics with checked and misplaced counts.
    • Exposed field-position metrics through runtime, WebAssembly, and JIT statistics interfaces.
    • Structural regressions now report increases in field-position mismatches.
  • Bug Fixes

    • Improved field index and name resolution for reordered or merged layouts.
    • Preserved fallback behavior for ambiguous or unmatched fields.
  • Tests

    • Added coverage for reordered, shared-offset, and zero-sized fields.
    • Added allocation tests verifying recorded object references.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@youknowone, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a8e4e270-a65d-49c5-932f-40e20e42134e

📥 Commits

Reviewing files that changed from the base of the PR and between da5e6fb and c98b31d.

📒 Files selected for processing (16)
  • majit/majit-ir/src/descr.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_vable.rs
  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs
  • majit/majit-metainterp/src/jitcode/assembler.rs
  • majit/majit-metainterp/src/pyjitpl/dispatch.rs
  • majit/majit-metainterp/src/trace_ctx.rs
  • majit/majit-translate/src/codewriter/assembler.rs
  • pyre/check.py
  • pyre/pyre-jit-trace/src/descr.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
  • pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs
  • pyre/pyre-jit-trace/src/jitcode_runtime.rs
  • pyre/pyre-jit/src/lib.rs
  • pyre/pyre-wasm-runner/src/main.rs
  • pyre/pyre-wasm/src/lib.rs

Walkthrough

The PR adds producer-side field-position diagnostics, resolves descriptor indices by names or unique offsets, exports four counters through runtime and wasm interfaces, and adds allocation-rooting documentation and tests.

Changes

Field position diagnostics

Layer / File(s) Summary
Producer position census
majit/majit-ir/src/descr.rs, majit/majit-metainterp/src/pyjitpl/dispatch.rs, pyre/pyre-jit-trace/src/descr.rs
Descriptor factories and bridges record submitted field positions and attached parent indices.
Descriptor layout reconciliation
majit/majit-metainterp/src/jitcode/assembler.rs, majit/majit-translate/src/codewriter/assembler.rs, majit/majit-macros/src/jit_interp/jitcode_lower/*, majit/majit-metainterp/src/pyjitpl/dispatch.rs
Field names flow through field access emitters. Descriptor resolution uses names first and unique offsets second. Debug checks and regression tests validate merged-layout behavior.
Diagnostic publishing
pyre/pyre-jit-trace/src/jitcode_runtime.rs, pyre/pyre-jit/src/lib.rs, pyre/pyre-wasm/src/lib.rs, pyre/pyre-wasm-runner/src/main.rs, pyre/check.py
Runtime APIs, wasm exports, statistics output, and badness gates expose the four checked and misplaced counters.

Allocation rooting validation

Layer / File(s) Summary
Allocation rooting checks
pyre/pyre-jit-trace/src/trace_ctx.rs, pyre/pyre-jit-trace/src/jitcode_dispatch/*
Documentation describes allocation root lifetimes. Backend tests verify that allocation results are stamped onto recorded operations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DescriptorFactories
  participant GcCache
  participant JitcodeRuntime
  participant WasmExports
  participant WasmRunner
  DescriptorFactories->>GcCache: Record field-position checks
  GcCache->>JitcodeRuntime: Provide checked and misplaced counts
  JitcodeRuntime->>WasmExports: Expose field_position_counts
  WasmExports->>WasmRunner: Return four counters
  WasmRunner->>WasmRunner: Emit MAJIT_STATS
Loading

Possibly related PRs

Suggested reviewers: lifthrasiir

Poem

A rabbit checks each field in line,
Counts each slot by design.
Descriptors find their proper place,
Wasm carries counts with grace.
Roots hold allocations tight—
The JIT hops onward, right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: detecting field-position invariant violations at their producer sites instead of repairing them later.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc-decouple

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ba7db5471

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

&& let Some(pos) = parent_spec
.all_fielddescrs
.iter()
.position(|spec| spec.offset == offset)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep fallback fields from borrowing sibling slots

When this fallback handles a field that was deliberately omitted from all_fielddescrs (for example an inline aggregate/nested struct, or a zero-sized layout-only field) but shares its byte offset with a real leaf, this offset-only search stamps the leaf's slot onto the missing field. That is exactly the found_parent_field == false case; downstream virtual materialization looks up fields by all_fielddescrs()[index_in_parent], so the aggregate/layout-only field can be emitted through the sibling leaf descriptor instead of staying unresolved. Only re-rank here when the parent entry also matches the field key/name; otherwise keep the unresolved fallback.

AGENTS.md reference: AGENTS.md:L231-L233

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit c98b31d).
Updated: 2026-08-05T20:24:12.733Z

Files in the reviewed diff
majit/majit-ir/src/descr.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_vable.rs
majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs
majit/majit-metainterp/src/jitcode/assembler.rs
majit/majit-metainterp/src/pyjitpl/dispatch.rs
majit/majit-metainterp/src/trace_ctx.rs
majit/majit-translate/src/codewriter/assembler.rs
pyre/check.py
pyre/pyre-jit-trace/src/descr.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/fbw_state.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/mod.rs
pyre/pyre-jit-trace/src/jitcode_dispatch/tests.rs
pyre/pyre-jit-trace/src/jitcode_runtime.rs
pyre/pyre-jit/src/lib.rs
pyre/pyre-wasm-runner/src/main.rs
pyre/pyre-wasm/src/lib.rs

1. Regressions to PyPy parity introduced by this patch

None.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

None.

4. Structural adaptations

  • majit/majit-metainterp/src/jitcode/assembler.rs:5648 ↔ rpython/jit/backend/llsupport/descr.py:218 — pyre must finalize BhDescr::Field parents and indices after staged Rust bytecode emission; PyPy constructs the descriptor directly from one complete STRUCT. The new finalization restores the same field/parent pairing.

  • majit/majit-macros/src/jit_interp/jitcode_lower/lower_value.rs:586 ↔ rpython/jit/codewriter/heaptracker.py:97 — Rust lowering now carries stringify!(member) alongside offset/type ID. PyPy already receives fieldname; this is necessary because pyre’s emitted bytecode otherwise loses the name needed to distinguish flattened same-offset fields.

  • majit/majit-translate/src/codewriter/assembler.rs:3868 ↔ rpython/jit/codewriter/heaptracker.py:97 — the unique-offset fallback is a CPython-compatible compiler/layout adaptation for a field absent from pyre’s flattened field-name registry. It deliberately declines ambiguous offsets; PyPy’s low-level STRUCT representation instead resolves by field name directly.

  • majit/majit-ir/src/descr.rs:737 ↔ rpython/jit/backend/llsupport/descr.py:218 — producer-side atomic censuses have no PyPy runtime counterpart. They only detect violations before pyre’s cache reconciliation and do not alter descriptor semantics.

  • pyre/pyre-wasm/src/lib.rs:496 ↔ rpython/jit/codewriter/heaptracker.py:97 — wasm exports and runner-side stat collection are target-specific diagnostics required because wasm has no native stderr path; they do not change PyPy-equivalent JIT behavior.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@youknowone

Copy link
Copy Markdown
Owner Author

Codex parity review — §1 and §2 confirmed and fixed

Both findings were about the same thing and both were real, so I measured
rather than argued: a temporary probe counting, per parent field list, how many
specs sit at an offset another field of the same parent also occupies.

[probe] spec_dup_offset=7      (of field_pos_spec_checked=1714)

Offset is not an identity. A flattened layout puts an inline aggregate and its
first leaf at one address (heaptracker.py:68-69 recurses into a nested
lltype.Struct without minting a descr for the container), so
position(|fd| fd.offset == offset) can name a sibling — exactly as the review
says.

What changed

field_slot_in (jitcode/assembler.rs) keys on the field name
(heaptracker.py:60-72 get_fielddescr_index_in(STRUCT, fieldname)) whenever the
descr carries one, and falls back to the byte offset only where there is no name
— and then only when exactly one field sits there. Where the offset is
ambiguous it resolves to nothing and the caller's number stands, the same
refusal descr.rs find_index_in_parent already documents.

Applied at all four sites the review named:

  • patch_field_descr_parents (§1) and its debug postcondition (§1, §2) — the
    check is now stated over field_slot_in, so it can no longer panic on a descr
    that names its field correctly and merely shares an address with a sibling.
  • fielddescrof's fallback arm and its census (§2) — unique_slot_at_offset.
    Reaching that arm means the name lookup already missed, so offset is all there
    is; declining on ambiguity is the only sound option.
  • field_descr_ref_from_bh's census (§2) — name first, unambiguous offset
    otherwise. Counting an ambiguous one would have put a false reading behind
    JITSTATS_BADNESS_FIELDS.

The pyre-jit-trace make_descr_from_bh census already keyed on
(offset, field_key), so it was unaffected.

§3 — one of the two pre-existing items is now closed

add_struct_field_descr has no field-name input and has always selected the
first matching offset.

Correct, and it was feeding a guessed name into the resolution above, which
is the damaging half: that name becomes the descr's _cache_field key
downstream, so naming the aggregate hands the leaf's access the aggregate's
descr. It now takes the documented (0, String::new()) fallback on an ambiguous
offset instead of the first match. The missing fieldname parameter itself is
still a real gap and is not something this PR closes.

The nested-struct flattening item (§3, second) is untouched — that is the same
rewrite_op_getsubstruct lowering the 152 field_pos_unresolved are waiting
on, and it is out of scope here.

Tests

  • an_ambiguous_offset_is_left_alone_rather_than_arbitrated — control:
    arbitrating fails it (0, "agg") vs (0, "").
  • a_named_field_resolves_by_name_through_an_ambiguous_offset — control:
    Some(1) vs None.

Verification

check.py dynasm 379/379, cranelift 379/379, wasm 375/375;
cargo test --workspace 101 suites, no failures. Counters unchanged and healthy
(field_pos_spec_misplaced=0, field_pos_attached_misplaced=0,
field_pos_unresolved=152).

The macos-latest check.py failure is not from this PR

Five max-pypy-ratio rows in the getattr/metatype family, three of which
check.py itself labels [pypy exec clamped to floor; ratio not a measurement].
main at 295b3fcad failed its macOS check.py with the same fixture, the
same 5.9x, the same clamped 0.01s denominator
:

FAIL cranelift synth/metatype_property_dunder  exec 0.04s > pypy 0.01s  ratio 5.9x > gate 5x

The gated counters this PR adds read 0 on that CI run
(field_pos_spec_misplaced=0 field_pos_attached_misplaced=0).

commented by Claude

@youknowone

Copy link
Copy Markdown
Owner Author

§1 and §2 are now None, and §3's first item is closed

The re-run reports no regressions and no new mismatches. §3's first item was
written against the previous push and is addressed by e7e04dde53c:

Pyre's add_struct_field_descr(offset, field_type, type_id) has no
fieldname … cannot select the field PyPy's name-keyed
heaptracker.get_fielddescr_index_in() selects.

The name was already there and simply not passed. jitcode_lower hands
register_struct_layout a (offset, is_ref, stringify!(#member)) triple and
then, in the same quote! expansion, called setfield_gc_* /
getfield_gc_* with the offset alone. Threading stringify!(#member) through
the five emitters makes read and write agree by construction and turns
field_slot_in into a real get_fielddescr_index_in(STRUCT, fieldname). The
unique-offset fallback stays for a caller that has no name to give.

an_ambiguous_offset_is_arbitrated_by_name_and_otherwise_declined now pins both
halves: the named access resolves to slot 1 ("leaf") through an offset two
fields share, the unnamed one still declines to (0, "").

The two remaining §3 items are deferred, with reasons

  • all_fielddescrs ordered by byte offset vs STRUCT._names traversal
    order
    — a real ordering substitution, and the sharper of the two: it can
    assign a different index_in_parent than upstream wherever declaration order
    and offset order disagree, which no amount of name-keying fixes.
    field_specs_from_layout sorts by offset deliberately (a Rust struct literal
    has no stable source order across literals of the same type), so closing it
    needs the declared field order carried from the front end — the same channel
    the accessed-subset residual needs. Not something to bolt on inside this diff.
  • inline aggregate vs leaf at one address — this is the
    jtransform.py:942 rewrite_op_getsubstruct lowering, the same one the 152
    field_pos_unresolved are waiting on. Until a raw substruct access lowers to
    int_add(ptr, offset) with no descr, an aggregate access has no exact
    FieldDescr to be.

Verification

check.py dynasm 379/379, cranelift 379/379, wasm 375/375;
cargo test --workspace 101 suites, no failures. field_pos_* and
positional_* unchanged (spec_misplaced=0, attached_misplaced=0,
unresolved=152).

commented by Claude

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8feea75759

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3730 to +3734
if !found_parent_field
&& let Some(parent_spec) = parent.as_ref()
&& let Some(pos) = unique_slot_at_offset(&parent_spec.all_fielddescrs, offset)
{
index_in_parent = pos;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Leave name-missing fields unresolved

Fresh evidence in this revision is that the new unique_slot_at_offset re-rank still runs after the parent-name lookup has failed, so a field intentionally omitted from all_fielddescrs but sharing an offset with a single leaf (for example a flattened inline aggregate or a zero-sized layout-only field) gets stamped with that leaf's slot. The resulting descr keeps the omitted field's key/offset but points index_in_parent at a sibling, and later virtual materialization indexes all_fielddescrs()[index_in_parent], emitting through the sibling descriptor instead of leaving the field unresolved; upstream derives this by field name and does not invent an offset match for nested structs/void fields. Please only re-rank when the parent entry also matches the field key/name.

AGENTS.md reference: AGENTS.md:L231-L232

Useful? React with 👍 / 👎.

`execute_new`'s result is already reachable: the dispatch arm stamps it onto the
recorded op's `value` cell (`set_opref_concrete` → `history.py:803-807
*FrontendOp(pos, value)`) and `MetaInterp::walk_active_trace_refs` forwards
every recorder `Op`/`InputArg` `value` holding a `Value::Ref`.  Nothing between
`bh_new` and that stamp allocates from the GC heap, so no collection can observe
the object first.

Record that contract on `execute_new_allocation` and on the two dispatch arms,
and pin the stamp with `new_stamps_its_allocation_onto_the_recorded_op` /
`new_with_vtable_...` — the walker already had a test, but nothing held the
arms to stamping at all.  A stub `Backend` overriding `bh_new` /
`bh_new_with_vtable` exercises them; it needs real backing storage because
`new_with_vtable` writes `w_class` into the block it returns.

Also note on `FBW_FINISH_CONCRETE` why it is a genuine exception: the compile
path does `self.tracing.take()`, so that stash outlives the op graph and is the
one window no op-graph slot covers.

Assisted-by: Claude
…e parent

`add_struct_field_descr` derives `index_in_parent` and `name` by locating
`offset` in the `struct_size_specs` entry as it stands at the mint site, and
`register_struct_layout` re-indexes every entry on each merge, so a field minted
before a lower-offset sibling is registered carries a rank the merge invalidates.

`patch_field_descr_parents` already replaces that snapshot with the final merged
spec, which leaves the descr's two halves describing different fields: the
`descr.py:228` index points at a slot the `descr.py:238` parent fills with
another offset. Re-resolve both against the spec being installed, using the
mint's own `position(|fd| fd.offset == offset)` lookup over the complete list
rather than a prefix of it. A field absent from the final spec keeps what the
mint left it — those are the inline aggregates the flattened layout covers only
through their leaves.

`field_descr_index_follows_the_final_layout_not_the_mint_time_prefix` emits
high-offset-first and asserts `all_fielddescrs[index_in_parent].offset` is the
field itself; without the re-resolution it reports index 0 against a parent
whose slot 0 is the other field.

Measured on a hot two-field-instance loop: `field_pos_rederived` and
`positional_misplaced` are 0 on both sides, so the runtime's by-name
`derive_index_in_parent` was masking the stale index wherever the name resolved;
`field_pos_unresolved` is 152 on both sides. `field_pos_parent_absent`
1210 -> 1201, `key_compared` 1210 -> 1201, `positional_slots` 1279 -> 1270,
deterministic across runs.

Assisted-by: Claude
`field_pos_rederived` counted disagreements found by `derive_index_in_parent`,
which replaces the caller's `index_in_parent` in the same expression and only
runs on the mint path with a published, non-empty parent whose field name
resolves.  `make_simple_descr_group_keyed_with_headerless` mints every field
before `register_keyed_size` publishes the parent, so most mints take the
`FIELD_PARENT_ABSENT` early return and are never compared.

Add two censuses taken before that:

* `field_pos_spec_{checked,misplaced}` — `field_specs[i].index_in_parent == i`
  on the list as handed to either group factory, covering the `cache_key == 0`
  groups that never enter `_cache_size`.
* `field_pos_attached_{checked,misplaced}` — a standalone field descr's
  `index_in_parent` against the slot its own attached parent puts the offset
  in, counted at both `BhDescr::Field` readers.

Both read 0 over the corpus (1713 and 248 checked), as do `positional_misplaced`
and `field_pos_rederived`, with and without `patch_field_descr_parents`'
index/name re-resolution — the ordering the pass fixes does not occur there.
So add `field_descr_position_disagreement`, a `debug_assert!` postcondition over
every emitted `Field` descr at `try_finish`, covering the resolved case, the
unresolved `(0, "")` fallback and the descrs whose `type_id` the pass skips.
Removing the two re-resolution lines trips it in
`field_descr_index_follows_the_final_layout_not_the_mint_time_prefix`.

Gate the two `misplaced` counters in `JITSTATS_BADNESS_FIELDS`; absent from a
baseline they read 0, so no re-record.

check.py dynasm 378/378, cargo test --workspace 101 suites, no failures.

Assisted-by: Claude
…lback arms

`fielddescrof`'s `struct_layout_for` and `heuristic_field_layout` arms overwrite
`offset` from a second source and leave `index_in_parent` at its `0`
initialiser, while `parent` still carries the whole flattened list — the descr
names slot 0 of a list whose slot 0 is another field, and
`all_fielddescrs()[index_in_parent]` is what `optimizeopt/info.rs force_box`
indexes by.  Resolve the slot against the attached parent by that offset.  The
`found_parent_field` arm is left alone: it picked its spec by name, so
re-resolving by offset would return the wrong one for a struct with two fields
at one offset.

Census the same two halves at this producer, which stays meaningful after the
fix — the `found_parent_field` arm takes the matched spec's stored
`index_in_parent`, not its position in the list.

`field_pos_attached_misplaced` reads 0 before and after over the corpus, so
`fielddescrof_resolves_the_slot_when_the_offset_comes_from_the_layout_registry`
constructs the state directly; removing the re-resolution fails it 0 vs 1.

Replace the `debug_assert!` added in 6a5e644dd6d with a `cfg!(debug_assertions)`
branch: the message needs the same walk as the predicate and `debug_assert!` ran
it twice.

check.py dynasm 378/378 and cranelift 378/378, cargo test --workspace 101
suites, no failures.

Assisted-by: Claude
…wasm

`JITSTATS_BADNESS_FIELDS` gained `field_pos_spec_misplaced` and
`field_pos_attached_misplaced`, but the wasm guest has no stderr and never
printed the `field_pos_*` line, so `_jit_stats_change` read both as absent and
therefore zero — the healthy value.  Export them the way the `descr_set_*`
counters already are, with `pyre-wasm-runner` printing the line and failing the
run when an export is missing.

Not redundant with the native readings: the invariant is that `index_in_parent`
names the slot the field's byte offset occupies, and wasm32 lays structs out on
a 4-byte word (`symbolic.py:12 WORD = sizeof(lltype.Signed)`).  The wasm run
reports its own denominators — `field_pos_spec_checked=1665`,
`field_pos_attached_checked=245` against 1712/247 on this host — so the exports
measure the wasm-side descr universe rather than echoing the native one.

`*_checked` are exported on the same footing as `pyre_jit_descr_set_resolved`:
host-dependent, printed for diagnosis, absent from `JITSTATS_SNAPSHOT_FIELDS`.

check.py wasm 374/374, dynasm 378/378, cargo test --workspace 101 suites, no
failures.

Assisted-by: Claude
…ous offset

Codex parity review found the field-slot resolution keyed on byte offset where
`heaptracker.py:60-72 get_fielddescr_index_in(STRUCT, fieldname)` keys on the
name.  Measured: 7 of 1714 submitted field specs sit at an offset another field
of the same parent also occupies — a flattened inline aggregate and its first
leaf share an address (`heaptracker.py:68-69`) — so offset is not an identity
and `position(|fd| fd.offset == offset)` can name a sibling.

`field_slot_in` resolves by name when the descr carries one and falls back to
the offset only when it does not, and then only when exactly one field sits
there.  `add_struct_field_descr` is such a site: `getfield_gc_*` / `setfield_gc_*`
reach it with `(offset, type, type_id)` and no `fieldname`.  Where the offset is
ambiguous it now takes the `(0, String::new())` fallback instead of the first
match — a guessed name is the damaging half, since it becomes the descr's
`_cache_field` key downstream.

Same rule at the other three sites the review named: the codewriter's
`fielddescrof` fallback arm (`unique_slot_at_offset`), and the two
`census_attached_index` callers, which would otherwise report a
correctly-named descr as misplaced and put a false reading behind
`JITSTATS_BADNESS_FIELDS`.  The `pyre-jit-trace` census already keyed on
`(offset, field_key)`.

Two tests: an ambiguous offset leaves the mint's number alone (control:
arbitrating fails it `(0, "agg")` vs `(0, "")`), and a name resolves through an
ambiguous offset (control: `Some(1)` vs `None`).

check.py dynasm 379/379, cranelift 379/379, wasm 375/375; cargo test --workspace
101 suites, no failures.

Assisted-by: Claude
`descr.py:218-239 get_field_descr(gccache, STRUCT, fieldname)` keys on the
field name, and `heaptracker.py:60-72 get_fielddescr_index_in(STRUCT,
fieldname)` derives the slot from it.  `add_struct_field_descr` had no such
argument and stood the byte offset in for it, which cannot select between two
fields a flattened layout puts at one address.

The name was already at the emit sites: `jitcode_lower` hands
`register_struct_layout` a `(offset, is_ref, stringify!(#member))` triple and
then called `setfield_gc_*` / `getfield_gc_*` with the offset alone, in the same
`quote!` expansion.  Thread it through the five emitters so read and write agree
by construction.

`field_slot_in` now resolves those descrs by name; the unique-offset fallback
stays for a caller with no name to give.

check.py dynasm 379/379, cranelift 379/379, wasm 375/375; cargo test --workspace
101 suites, no failures.  `field_pos_*` and `positional_*` unchanged.

Assisted-by: Claude
`field_slot_in` and `patch_field_descr_parents` both said
`add_struct_field_descr` is handed `(offset, type, type_id)` and never sees a
`fieldname`.  It takes `field_name: &str`; every emit site supplies it —
`jitcode_lower` passes the same `stringify!(#member)` it hands
`register_struct_layout`, and `newlist_clear` passes the `"length"` / `"items"`
it registered.

The offset fallback is unchanged.  What reaches it is a recorded name the final
spec does not list, not a field that arrived anonymous.

Assisted-by: Claude

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c98b31d811

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// find_index_in_parent` documents for the runtime lookup, for the same reason.
fn field_slot_in(fields: &[BhFieldSpec], name: &str, offset: usize) -> Option<usize> {
if !name.is_empty()
&& let Some(idx) = fields.iter().position(|fd| fd.name == name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require matching offset for duplicate field names

When register_struct_layout has merged multiple layouts under one type_id, the list can contain the same field name at different offsets (the surrounding diagnostics track this as key_conflicting, e.g. the documented last_instr collision). This name-only lookup returns the first matching name even when the current descr’s offset is the later slot, and the new debug postcondition reuses the same helper without checking slot.offset, so it will bless an index_in_parent that points at a different field than the load/store offset. Please require the named slot to also match the descr offset, or treat duplicate names as unresolved/ambiguous.

Useful? React with 👍 / 👎.

@youknowone
youknowone merged commit df18a26 into main Aug 5, 2026
23 of 29 checks passed
@youknowone
youknowone deleted the gc-decouple branch August 5, 2026 22:49
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 6, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 7, 2026
15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 7, 2026
The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude
youknowone added a commit that referenced this pull request Aug 7, 2026
…x argument-handling sites (#1063)

* builtins: layout-checked object.__new__, buffer request kinds, and six argument-handling sites

`object.__new__` now runs `check_user_subclass`, and `type` gets its own
Layout typedef (`TYPE_TYPE`) instead of sharing `object`'s — that identity is
what the check reads, so `object.__new__(int)` and `object.__new__(<metaclass>)`
are refused.

Buffer requests are split by kind: `bytes()` / `bytearray()` read their source
with `BUF_FULL_RO` (a strided memoryview is copied out, not refused), while
bytes-method operands — `replace`, `strip`, `join`, `translate`, the fill char —
require the C-contiguity `BUF_SIMPLE` carries.

`bytes.startswith` / `endswith` convert the operand before the
`start > len(value)` early-out, so an empty window no longer hides a prefix of
the wrong type.

A supplied `None` is a value, not an omitted argument, for `bytes.center` /
`ljust` / `rjust`'s fill char, `bytes.decode`'s encoding and errors,
`bytearray.pop`'s index, and `memoryview.cast`'s shape.  `builtin_str` spells
the utf-8 default out where it previously passed `None` through.

Surplus positional arguments are rejected by `str.replace`, `bytes.center` /
`ljust` / `rjust`, `bytearray.remove` and `memoryview.cast`.

An unset `__slots__` read reports `%T` — the bare type name — through
`raiseattrerror`, matching the same miss taken through the descriptor's
`__get__`.  `type(1, (), {})` names argument 1 instead of reporting an arity
error, and argument 1's message says `string` like its two siblings.

`SyntaxError.__str__` splits its filename with `ntpath` rules on windows.

Assisted-by: Claude

* bytearray: compare against any BUF_SIMPLE exporter, not only bytes-like

`cmp_guard_bytearray` admitted only bytes and bytearray, so
`bytearray(b'ab') == array.array('B', [97, 98])` answered `False` and
`bytearray(b'ab') < memoryview(b'b')` raised.  `descr_eq` / `descr_ne` /
`_comparison_helper` (bytearrayobject.py) hand a non-bytes-like operand to
`space.acquire_py_buffer(w_other, space.BUF_SIMPLE)` and turn only the
TypeError that raises into `NotImplemented`; a released view's ValueError and
a strided view's BufferError propagate.

The six dunders are now built from `bytearray_compare`, which keeps the
by-layout `compare_slot` for the bytes-like arms and for any receiver the slot
was not meant for, and reads the receiver's data after the acquisition since a
`__buffer__` slot is app-level code.

`bytes` keeps the narrow guard: its comparisons never acquire a buffer, which
is what makes `b'ab' == array.array('B', [97, 98])` `False`.

`ordering_satisfies` replaces the two spellings of the `_memcmp`-result
mapping in `descroperation`.

`pad_fillchar`'s doc records why `str.ljust` / `rjust` keep refusing a buffer
fill char: `convert_arg_to_w_unicode` decodes one, but CPython refuses it for
all three methods and pyre follows CPython there.

Assisted-by: Claude

* builtins: metaclass one-argument guard, declaring-type arity names, and nine argument-rejection sites

`type_descr_new` accepted `Metaclass(x)` as the one-argument `type(x)`
form and returned `x`'s type; `descr__new__` (typeobject.py:901-908)
takes that path only when the metatype is `type` itself.  Restore the
guard and report the count through the two wordings upstream splits it
into.

`type.mro` carried no declared arity, so `int.mro(1)` computed the MRO
and dropped the surplus argument.

A builtin's arity and keyword errors named the receiver's own class.
An instance receiver now names the class that declares the method
(`MyList([1]).append()` reports `list.append()`); a type receiver keeps
naming itself, which is what a builtin bound to a class reports.

Argument rejections reworded at nine sites:

- `issubclass()` arg 2 names the accepted kinds
- `raise X from Y` distinguishes its cause check from `e.__cause__ = x`
- `UnicodeTranslateError` / `UnicodeDecodeError` / `UnicodeEncodeError`
  `__init__` report the count they received
- `BaseExceptionGroup.__new__` names itself
- `int.to_bytes` / `int.from_bytes` name the total parameter count once
  the call passes every parameter, and the positional limit otherwise
- `bytes.decode` renders a `None` argument as `None`
- `str.center` / `ljust` / `rjust` name the type of a non-str fill char
  rather than reporting its length
- `float.fromhex` rejects a non-str operand

Assisted-by: Claude

* typedef: restore the fully-qualified name in the empty-slot AttributeError

`descr_member_get`'s miss reported `getfulltypename` before d8fc362
narrowed it to the bare `%T` name; `test.test_descr.test_slots` pins the
`module.__qualname__` form and the cpython_tests runner drives that module
through its dotted-identity driver, so the narrowing turned the gate red.
The unit test's name and expectation go back with it.

`test_bad_new` regains the `@support.impl_detail(cpython=False)` marker the
3.14.6 stdlib import replaced with CPython's `@unittest.expectedFailure`:
the layout check added in fdcce06 makes the test pass here, and an
unexpected success fails the module.

Assisted-by: Claude

* jit: virtualize BUILD_TUPLE at arity 2 and seed a `*args` callee's vararg local

`try_walker_specialize_newtuple_object` no longer declines arity 2. The
canonical array-backed `W_TupleObject` is the shape `subscr_tuple`,
`builtin_len`, `get_iter` and the array-backed arm of `unpack` already read,
whereas a `makespecialisedtuple2` pair has an UNPACK fold and nothing else,
so every other read of one forced it out of virtual state. The `spec_ii`
arm stays as the fallback for a pair whose backing-array length never
reached the heap-cache as a constant. Measured over an empty loop:
`(i, i + 1)[1]` 258.6ns -> 0.1ns, `f()[1]` for a pair-returning `f`
1247.7ns -> 34.4ns, `d[(a, b)]` 1207.6ns -> 313.1ns.

`try_walker_inline_resolved_user_call` accepts a `*args` callee and writes
`newtuple(starargs_w)` into `scope_w[co_argcount]`
(`argument.py:222-234 _match_signature`) instead of leaving the call
residual. `**kwargs` and keyword-only callees stay residual, as does a
zero-surplus call (the empty tuple is a singleton) and a bound method whose
callee has no positional parameter to hold the receiver — its
`callee_args[0]` is still the placeholder the resolved half replaces with
`GetfieldGcR(Method.w_self)`. 300k calls: `f(*args)` 0.480s -> 0.000s,
`c.m(*args)` 0.514s -> 0.000s, `f(a=i)` into `**kw` 0.254s -> 0.142s.

jit-stats: the trace-built pair and a runtime-built specialised one meeting
at one code location costs a side exit, so three fixtures gain a bridge
(`binary_int_overflow_local_resume`, `exc_bridge_entry_guard_not_removed`,
`list_append_write_barrier_gc`); `getattribute_override_no_bind` compiles
one loop instead of two now that its `*args` callee inlines, and
`pickle_ctor_args` sheds half its cranelift guard failures. The wasm
baseline missing for `exception_escape_hot_callee_tb_node_once` is recorded.

Assisted-by: Claude

* bench: re-record the jitstats baselines the rebase left disagreeing

15 synthetic fixtures move on all three backends: the arity-2 BUILD_TUPLE
virtualization composes with the walker setfield_gc store and the FOR_ITER
RETURN_VALUE admission from #1068 and the loop-perf folds from #1061, so the
sre, exception-traceback and comprehension traces take fewer side exits —
`nested_list_comprehension_hot` drops from 6 bridges / 1202 guard failures to
2 / 401, `sre_wasm_min` from 8 / 1849 to 5 / 1161.

The 30 macro baselines only gain `field_pos_attached_misplaced` and
`field_pos_spec_misplaced` at 0, the counters #1053 added to the binary
without recording them here.

Assisted-by: Claude

* bench: mark the thirteen fixtures cpython cannot usefully run

Each carries `# pyre-check: skip-cpython` followed by the measured cpython and
pyre times, the way the directive requires. The directive itself is on the
base; this only names the fixtures that claim it.

Assisted-by: Claude

* parity: cover the arity-2 specialised tuple consumers

`specialised_pair_consumers.py` reads the `_ii` / `_ff` / `_oo` pair layouts
through `len()`, subscription and unpacking, at a constant index, at an
alternating index and off a nested pair, with accumulators that do not cancel
a swapped or mis-represented slot.

The specialisation folds themselves are already on the base.

Assisted-by: Claude

* objspace: compare same-class specialised tuple pairs on their raw slots

`compare_slot`'s tuple arm walked both operands with `w_tuple_getitem`, which
for a `W_SpecialisedTupleObject_ii` / `_ff` builds a fresh box per element
because the payload is an inline machine word.

`specialised_tuple_same_class_eq` reproduces `specialisedtupleobject.py:113-127
descr_eq`: when both operands are the same specialised class the value slots
compare raw, with the float arm falling back to the bit pattern so the same NaN
in both slots stays equal (`float2longlong` upstream) while `+0.0` / `-0.0` are
caught by the value compare. `_oo` slots still go through `eq_w`. Eq/Ne only —
ordering keeps the generic walk, as upstream does. A mixed pair (one
specialised, one array-backed) falls through to the existing element walk.

Measured `(1, 2) == (1, 2)` on two loop-invariant pairs: 252.9ns -> 151.1ns.
The remainder is not the boxing: `_ff` barely moves and an arity-3 array-backed
comparison is 29ns, so ~120ns of arity-2 comparison is upstream of this arm.

Assisted-by: Claude

* bench: re-record the synthetic jitstats baselines from a fresh LLBC

The baselines committed in e3d151e were recorded against a stale
`build/llbc`: a `pyre-jit-trace` / `pyre-interpreter` edit invalidates the
extraction fingerprint, and the JIT reads the function bodies it inlines out
of those artefacts, so trace shape — not just field offsets — depends on them.
The recorded counters therefore did not reproduce on CI, which extracts its
own. `pyre/check.py (ubuntu-24.04)` failed with 49 jit-stats regressions
across 19 benches on all three backends with identical numbers.

Re-extracted `pyre-object pyre-interpreter pyre-jit`, rebuilt dynasm,
cranelift and wasm with no `LLBC STALE` warning, and re-recorded. A local run
now reproduces the CI numbers exactly, e.g. `nested_list_comprehension_hot`
bridges 2 -> 6 and guard_failures 401 -> 1202.

84 counter values change across 21 benches (51 guard_failures, 33
bridges_compiled). 19 are the arity-2 tuple fold's mixed-representation side
exits, which the ca7351f message under-reported for the same stale-artefact
reason. Two are improvements from the specialised-pair subscript fold:
`divmod_long_int_pair` guard_failures 9 -> 7 (its pair result now folds) and
`exception_oserror_fields` 202 -> 201.

The remaining 2218 added lines are `field_pos_attached_misplaced` /
`field_pos_spec_misplaced`, counters #1053 added to the binary without
recording them.

check.py --synthetic-only: dynasm 371/371, cranelift 371/371, wasm 370/370.

Assisted-by: Claude

* builtins: check the metatype is a type before naming it in type.__new__

`type_descr_new` reached `new_arity_message` with an unvalidated first
argument, and that read it through the `W_TypeObject` layout:
`type.__new__(42, 1)` segfaulted and `type.__new__('s', 1)` reported
`s.__new__() takes exactly 3 arguments (1 given)`, naming the str's own
bytes.

`descr__new__` (typeobject.py:886-911) decides the arity first and then
runs `_precheck_for_new` (typeobject.py:1001-1003), so the one-name form
now refuses a non-type with `X is not a type object (%T)` and the
no-name form names it through the `%N` operand spelling — `W_Root.getname`
(baseobjspace.py:90-94), which answers `?` when `__name__` is absent.
`type.__new__(42)` answered `<class 'int'>` and now raises.

Also folds the two `pos.len() == 1` arms, which had become the same
branch, and saturates the reported argument count in the three unicode
error initialisers; those are installed as `wrapper_descriptor`s that
reject a zero-argument call before the body runs, so the subtraction was
not reachable.

Assisted-by: Claude

* baseobjspace: hand the buffer request kind's flags to a `__buffer__` exporter

`buffer_bytes` passed a literal `0` to `w_memoryview_new_with_flags` on
every path, so a Python `__buffer__` saw `PyBUF_SIMPLE` even when the
caller was `full_ro_buffer_bytes`, whose request is `BUF_FULL_RO`. An
exporter that branches on the request observed the wrong one:
`bytes(x)` on a `__buffer__` that requires `PyBUF_FORMAT` raised
`BufferError` where cpython returns the bytes.

`require_contiguous: bool` becomes a `BufferRequest` naming the two
requests, and both the contiguity rule and the exporter flags are derived
from it. `BUF_FULL_RO` moves next to it from `interp_buffer`, which
already spelled the same constant.

Assisted-by: Claude

* jit: decline the object-slot arm of the specialised-pair subscript fold

`try_walker_specialize_subscr_specialised_pair` reaches
`W_SpecialisedTupleObject_oo.value0` / `value1` through
`walker_emit_specialised_pair_item`, which reads them with a `getfield_gc_r`.
That read is wrong code on this path. `test.test_datetime` holds
`self.lt = (array('q', ut), array('q', ut))` and reads `self.lt[dt.fold]`; with
the fold in place the next call in that frame comes out one positional argument
short, so `bisect.bisect_right(lt, timestamp)` raises `TypeError: bisect_right()
missing 1 required positional argument: 'x'` and the module goes `PASS -> FAIL`
on the CPython gate.

Measured on the full module, 550 tests: `PYRE_NO_JIT=1` passes while the JIT
fails one. Declining only the `Object` kind passes. `MAJIT_NO_BRIDGE=1` still
fails, so the exit is the main trace's and not a compiled bridge; executing the
residual for the object arm and recording its concrete result, dropping the
`replace_box`, and emitting the index guard through
`walker_emit_guard_with_snapshot` each leave it failing. What makes the
object-slot read itself wrong is not yet known.

The decline sits in the subscript entry point rather than in
`walker_emit_specialised_pair_item`, because UNPACK reaches the same slots
through that helper with no index operand and is sound. The `ii` and `ff` arms
share the class guard and the pinned index and keep their fold — over an empty
loop, `II[0]` 0.1ns and `II[i & 1]` 0.7ns against 169.3ns and 175.5ns with the
whole fold declined. `len()` on a pair is untouched. `OO[0]` returns to the
residual at 193.1ns from 35.9ns.

Assisted-by: Claude

* bench: restore the sre_wasm_min1 jit-stats baselines to what this tree measures

The rebase carried this branch's earlier recording through without raising a
conflict: bridges_compiled=4 and guard_failures=803. All three backends read 3
and 603 against the rebased tree, which is what main records.

Assisted-by: Claude

* bench: re-record getattribute_override_no_bind's wasm jit-stats baseline

The rebase resolved this file to main's side, which reads loops_compiled=2 and
guard_failures=2. The tree measures 1 and 1 on wasm, matching the dynasm and
cranelift baselines for the same fixture. The re-record also picks up the five
counters added to the snapshot field set.

Assisted-by: Claude

* jit: seed the vararg tuple into the inlined callee's concrete frame

The symbolic frame is built from `param_boxes`, which spans `seeded_locals`
and so carries the packed `*args` tuple; the concrete frame beside it was
built from the first `nparams` entries only. That frame is published on the
interpreter frame chain for the whole sub-walk, so a residual running inside
an admitted `*args` callee read the vararg name as unbound:

    def g(a, *args):
        return 'args' in sys._getframe().f_locals

called in a hot `while` loop answered False on 5 of 200000 iterations, where
pypy answers True on all of them. `_match_signature` writes the vararg tuple
into `scope_w` like any other local (argument.py:222-234).

`callee_arg_concretes` already holds the tuple at index `nparams` and is
declined unless its length is `seeded_locals`, so both bounds stay in range.

Assisted-by: Claude

* type_methods: word the fill-character refusal per padding method

`center` converts with `space.utf8_w` and `ljust`/`rjust` with
`convert_arg_to_w_unicode` (unicodeobject.py:1101, 175-184), and the two
refuse in different words. Both arms carried one shared string that matched
neither:

    "ab".center(6, 1)   pypy: expected str, got int object
    "ab".ljust(6, b"x") pypy: Can't convert 'bytes' object to str implicitly
    pyre, both:         The fill character must be a unicode character, not X

`arg_type_name` renders the same names `%T` does for all eight types checked.
`decode_object`, which turns a buffer operand into a fill char for
`ljust`/`rjust`, is still not imported; the doc comment now states that as the
remaining difference instead of as the reason for a shared message.

Assisted-by: Claude

* builtins: prebuild the default encoding `str` hands to bytes.decode

`builtin_str` wrapped a fresh "utf-8" for every `str(b, errors=...)` call that
omits the encoding. `w_str_new` is immortal, so each one stays allocated for
the life of the process. `warn::PrebuiltText` is the existing cell for this
shape; `bytes_method_decode` only reads the encoding through `str_utf8_w`.

Assisted-by: Claude

* type_methods: report a non-bytes fill operand the way decode_object does

`convert_arg_to_w_unicode` declines only `bytes` itself; every other non-str
operand reaches `decode_object`, which reports a failed conversion as
"decoding to str: %S" over the buffer error (unicodeobject.py:175-184,
1727-1739). The `ljust`/`rjust` arm now says that, with `None` rendered
unquoted where a type name is quoted:

    "ab".ljust(6, 1)     decoding to str: a bytes-like object is required, not 'int'
    "ab".ljust(6, None)  decoding to str: a bytes-like object is required, not None
    "ab".ljust(6, b"x")  Can't convert 'bytes' object to str implicitly

All eight cases checked now print what pypy prints, byte for byte.

Assisted-by: Claude

* type: precheck the metatype on the four-argument type.__new__ path

`type_descr_new` finds `(name, bases, dict)` by scanning for a str, so a
four-position call whose name is not a str falls past the scan.  That branch
took `pos[0]` as the metatype only when it already was a type and otherwise
left it null, which sent `type.__new__(42, 1, (), {})` on to report argument
1.  `descr__new__` runs `_precheck_for_new` once the count is settled and
before `_check_new_args` (typeobject.py:899), so the branch calls
`precheck_for_new` first:

    type.__new__(42, 1, (), {})
      before  TypeError: type() argument 1 must be string, not int
      after   TypeError: X is not a type object (int)

The five-argument `super()` shape and every call whose name is a str are
taken by the scan above and do not reach this branch.

Assisted-by: Claude

* jit: decline the arity-2 BUILD_TUPLE virtualization

`try_walker_specialize_newtuple_object` emitted a canonical `W_TupleObject`
virtual at every arity, including 2.  At that arity the interpreter calls
`makespecialisedtuple2` (specialisedtupleobject.py:169-179) instead, so the
virtual is the one shape the runtime never builds: `Cls_ii` / `Cls_ff` /
`Cls_oo` hold `value0` / `value1` inline and carry no `wrappeditems` block.

The trace is self-consistent on its own, but a side exit puts a real pair in
front of a consumer the trace chose for the canonical layout, and
`try_walker_specialize_subscr_specialised_pair` reads a field that is not
there.  A pair built inside the loop and subscripted at an alternating
non-negative index reaches it:

    t = (i, BIG)
    item = t[i & 1]

which segfaults, or returns whatever the stale pointer lands on — one run
answered `TypeError: unsupported operand type(s) for &: 'type' and 'int'`.
`extra_tests/parity_tests/subscr_specialised_pair_shapes.py` fails both ways.
Building the pair outside the loop, or indexing it only at a negative index,
does not reach it.

Arity 2 now falls to `try_walker_specialize_newtuple`, which builds the
specialised shape the runtime builds.  Arity 1 and 3 up are unchanged.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant